feat: chroma key (greenscreen) for the render pipeline - #183
Merged
Conversation
Removes a solid-colored background with a soft edge and spill suppression, and fills it with a color, an image, or — in a VideoComposition — the layer below, so a video can sit behind the screen. Settable on VideoRenderData, VideoLayer and VideoSegment, resolved per clip as segment -> layer -> global. The whole key (matte alpha and despilled color) is a pure function of RGB, which is what lets Apple do it through a CIColorCube with varying, premultiplied alpha — no CIKernel and no Metal, neither of which the repo has today. Android needs a real shader, because SingleColorLut cannot produce alpha; ChromaKeyEffect is modelled on the existing VideoCompositionTransformation. Both platforms run the same BT.601 chroma-distance formula, pinned by an identical golden table in the Kotlin and Swift unit tests so a drift in either fails fast instead of surfacing as "it looks different on iOS". ChromaKey.autoDetect measures the key color and similarity off the footage rather than guessing. A constant sits beside the *recorded* screen — a studio screen painted SMPTE green recorded as 0xFF2A9D37, a chroma distance of 0.12 — and that offset has to be paid for with a wider similarity, which is the margin that protects the subject. Measured on the demo clip: against the constant the screen sat 0.10-0.20 away and needed similarity 0.20; against its own measured color it sat within 0.05 and 0.10 sufficed. Detection runs through the existing thumbnail pipeline, so it is pure Dart and touches no native code. greenScreen()/blueScreen() presets carry the measured values. Blue is not green with a different hue: it separates skin better (0.41-0.46 vs 0.38-0.42) but denim (0.19), blue eyes (0.20) and light blue shirts (0.21) all sit inside the green-tuned default, so the blue preset keys tighter and despills more gently. Two pre-existing bugs surfaced and are fixed here: - VideoCompositionTransformation blended each composition layer against an already-transparent framebuffer, squaring its alpha. A no-op while every layer was opaque; load-bearing once a keyed layer arrives with a soft edge. - EditorVideo.safeFilePath named its temp file from the millisecond alone, so sources resolved concurrently (as VideoRenderData.toAsyncMap does) could overwrite each other. Verified end to end on macOS and a Galaxy S25: 14 chroma-key integration tests plus the existing render, composition, image-layer, layer-animation and render-pixel suites.
- Detector: the coverage guard was unreachable. `coverage` was measured against a band derived from the distances' own 99th percentile, so it came out ~0.99 for every possible frame and `detect` never rejected a border that was not a clean screen. Use a constant band, and measure `spread` over the covered pixels only so a subject at the border cannot widen the key. - Darwin: the chroma-key window end-clamp keyed every frame after the last keyed clip. Windows are emitted per keyed clip, so the array is sparse; bound the clamp to one frame. - Android: the HDR pre-transcode only rewrote `videoClips`, so an HDR clip on a composition layer reached the ES 2.0 shader and hard-failed the export. Retarget the composition layers' clips too. - Both: a transition blend with mismatched keys was logged as "emitted unkeyed" but `null` means inherit, so the global key was applied to it anyway. Add `suppressChromaKey` and compare the resolved keys, which also drops the spurious warning when both sides resolve to the same key. - Android: the background image was sampled with the frame's texture coordinate, where t=0 is the bottom, while GLUtils.texImage2D uploads row 0 at t=0 — so it rendered upside down. Flip the background lookup. - Both: `backgroundColor` alpha was honored on Darwin and dropped on Android. Ignore it on both and assert the color is opaque. - Both: a background image that will not decode fell back to transparent on Android and black on Darwin. Fill with opaque black on both. - Android: downscale a background bitmap to GL_MAX_TEXTURE_SIZE instead of throwing, recycle the placeholder, and release the program even when deleting the texture throws. - Darwin: identify a cached background by content, not byte length, and run the key before the layer transform so it sees unresampled pixels. - `ChromaKey.copyWith` could not switch between the two background types and could not clear them; add `removeBackground`. - `ChromaKey.detect` never disposed its codecs or images. - The H.264-alpha assert only inspected the global key, not per-segment. - Correct the docs claiming the key color's brightness is ignored.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Removes a solid-colored background with a soft edge and spill suppression, and fills it with a color, an image, or — in a
VideoComposition— the layer below, so a video can sit behind the screen.Settable on
VideoRenderData,VideoLayerandVideoSegment, resolved per clip as segment → layer → global.How it works
The whole key — matte alpha and despilled color — is a pure function of RGB. That is what lets Apple do it through a
CIColorCubewith varying, premultiplied alpha: noCIKernel, no Metal, neither of which this repo has today. Android needs a real shader, becauseSingleColorLutcannot produce alpha;ChromaKeyEffectis modelled on the existingVideoCompositionTransformation.Both platforms run the same BT.601 chroma-distance formula, pinned by an identical golden table in the Kotlin and Swift unit tests — so a drift in either implementation fails a fast test instead of surfacing later as "the key looks different on iOS".
Why
autoDetectA constant key sits beside the recorded screen. The demo clip's screen is painted SMPTE green (
0xFF00B140); the camera recorded0xFF2A9D37— a chroma distance of0.12. That offset has to be paid for with a widersimilarity, which is exactly the margin protecting the subject.Same footage, roughly twice the headroom. Detection samples a border ring through the existing thumbnail pipeline, so it is pure Dart and touches no native code.
ChromaKey.detectreturns the raw measurement (color, spread, border coverage) for showing to the user, and throwsChromaKeyDetectionExceptionwhen the border is not a clean screen.greenScreen()/blueScreen()presets carry the measured values. Blue is not green with a different hue — it separates skin better (0.41–0.46 vs 0.38–0.42) but denim (0.19), blue eyes (0.20) and light blue shirts (0.21) all sit inside the green-tuned default, so the blue preset keys tighter and despills more gently.Two pre-existing bugs fixed along the way
VideoCompositionTransformationsquared each composition layer's alpha. It blended against an already-transparent framebuffer (dst.a = a² + 0·(1−a)). A no-op while every layer was opaque; load-bearing once a keyed layer arrives with a soft edge. Confirmed against Media3's ownDefaultCompositorGlProgram, which usesglBlendFuncSeparateand does the one real blend.EditorVideo.safeFilePathnamed its temp file from the millisecond alone, so sources resolved concurrently — asVideoRenderData.toAsyncMapdoes — could overwrite each other.Not covered
ColorFilter); split the clip instead.ClipTransitionwhen the two clips carry different keys — the blend is pre-rendered from raw sources and is emitted unkeyed with a warning.videoSegmentspath a key without a background is flattened to black. There is a debug assert pointing atcomposition.Verification
chroma_key_testvideo_render,composition,layer_animation,image_layer,render_pixel— all greenThe example app gets its own Chroma Key page with a "measure the screen" button, a color/image/video background switch and sliders for all three parameters. The demo asset is a real studio green-screen clip, cropped to the screen area and downscaled to 330 KB.
Separately noted while building the test fixtures, not touched here: two
renderStopMotionoutputs do not concatenate (2s + 2s comes out as 2.03s on macOS). Unrelated to this change, but it looks like a real bug.